/*
 * glu: webgl helpers
 *
 * Copyright (c) 2020 Cameron Beccario
 */

// import * as _ from "../lib/underscore.js";
import * as _ from "underscore";
import PLANE_VERTEX_SHADER from "./plane.vert";
import {isArrayLike, mapNaNtoNIL} from "../util/arrays.js";

// see http://webglfundamentals.org/webgl/lessons/webgl-resizing-the-canvas.html
//     http://webglfundamentals.org/webgl/lessons/webgl-anti-patterns.html

function throwOnErr(msg) {
    throw new Error(msg);
}

/**
 * @param {Float32Array} pixels
 * @returns {Float32Array}
 */
function prepareMissing(pixels) {
    return (pixels.containsNaN ?? true) ? mapNaNtoNIL(pixels) : pixels;
}

export class GLU {

    /**
     * @param {HTMLCanvasElement} canvas DOM element
     * @param {Object} [attributes] WebGL context attributes
     * @returns {WebGLRenderingContext} the context or undefined if not supported.
     */
    static getWebGL(canvas, attributes) {
        let gl;
        try { gl = canvas.getContext("webgl", attributes); } catch(ignore) {
          // do nothing!
        }
        if (!gl) {
            try { gl = canvas.getContext("experimental-webgl", attributes); } catch(ignore) {
              // do nothing!
            }
        }
        return gl || undefined;
    }

    /** @returns {string} */
    static planeVertexShader() {
        return PLANE_VERTEX_SHADER;
    }

    static unitPlaneAttributes() {
        return {
            a_Position: new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
            a_TexCoord: new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
        };
    }

    /**
     * @param {WebGLRenderingContext} gl
     * @param {Function} [ƒerr] ƒ(err) invoked when an error occurs (default is to throw).
     * @returns {GLUStick}
     */
    static attach(gl, ƒerr = throwOnErr) {

        const defaultPixelStore = {
            PACK_ALIGNMENT: 1,
            UNPACK_ALIGNMENT: 1,
            UNPACK_FLIP_Y_WEBGL: false,
            UNPACK_PREMULTIPLY_ALPHA_WEBGL: false,
            UNPACK_COLORSPACE_CONVERSION_WEBGL: gl.BROWSER_DEFAULT_WEBGL,
        };

        const defaultPixelStoreKeys = Object.keys(defaultPixelStore);

        const defaultTexParams = {
            TEXTURE_MIN_FILTER: gl.NEAREST,
            TEXTURE_MAG_FILTER: gl.NEAREST,
            TEXTURE_WRAP_S: gl.CLAMP_TO_EDGE,
            TEXTURE_WRAP_T: gl.CLAMP_TO_EDGE,
        };

        const defaultTexParamKeys = Object.keys(defaultTexParams);

        function check(tag) {
            const num = gl.getError();
            if (num) {
                ƒerr(`${num}:${tag}`);
            }
        }

        return new class GLUStick {

            /** @returns {WebGLRenderingContext} */
            get context() {
                return gl;
            }

            /** @returns {WebGLShader} */
            makePlaneVertexShader() {
                return this.makeShader(gl.VERTEX_SHADER, PLANE_VERTEX_SHADER);
            }

            unitPlaneAttributes() {
                return {
                    a_Position: new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
                    a_TexCoord: new Float32Array([0, 0, 1, 0, 0, 1, 0, 1, 1, 0, 1, 1]),
                };
            }

            /**
             * @param {number} type either VERTEX_SHADER or FRAGMENT_SHADER.
             * @param {string} source shader source code.
             * @returns {WebGLShader} the shader object, or null if the shader could not be compiled.
             */
            makeShader(type, source) {
                const shader = gl.createShader(type);                               check(`createShader:${type}`);
                gl.shaderSource(shader, source);                                    check(`shaderSource:${type}`);
                gl.compileShader(shader);                                           check(`compileShader:${type}`);
                const status = gl.getShaderParameter(shader, gl.COMPILE_STATUS);    check(`getShaderParameter:${type}`);
                if (!status) {
                    const message = gl.getShaderInfoLog(shader);                    check(`getShaderInfoLog:${type}`);
                    gl.deleteShader(shader);                                        check(`deleteShader:${type}`);
                    ƒerr(message);
                    return null;
                }
                return shader;
            }

            /**
             * @param {WebGLShader[]} shaders the compiled shaders.
             * @returns {WebGLProgram} the program, or null if the program could not be linked.
             */
            makeProgram(shaders) {
                const program = gl.createProgram();                                 check("createProgram");
                shaders.forEach(shader => {
                    gl.attachShader(program, shader);                               check("attachShader");
                });
                gl.linkProgram(program);                                            check("linkProgram");
                const status = gl.getProgramParameter(program, gl.LINK_STATUS);     check("getProgramParameter");
                if (!status) {
                    const message = gl.getProgramInfoLog(program);                  check("getProgramInfoLog");
                    gl.deleteProgram(program);                                      check("deleteProgram");
                    ƒerr(message);
                    return null;
                }
                return program;
            }

            /**
             * @param {WebGLTexture} texture 2d texture
             * @returns {WebGLFramebuffer} the framebuffer, or null if the framebuffer is not complete.
             */
            makeFramebufferTexture2D(texture,callback) {
                const framebuffer = gl.createFramebuffer();                         check("createFramebuffer");
                gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);                    check("bindFramebuffer");
                gl.framebufferTexture2D(
                    gl.FRAMEBUFFER,
                    gl.COLOR_ATTACHMENT0,
                    gl.TEXTURE_2D,
                    texture,
                    0);                                                             check("framebufferTexture2D");
                const status = gl.checkFramebufferStatus(gl.FRAMEBUFFER);           check("checkFramebufferStatus");
                if (status !== gl.FRAMEBUFFER_COMPLETE) {
                    gl.deleteFramebuffer(framebuffer);                              check("deleteFramebuffer");
                    callback("framebuffer: " + status);
                    return null;
                }
                return framebuffer;
            }

            /**
             * @param {WebGLProgram} program
             * @param {Object} textures map from name to texture entry
             * @returns {GLUUniforms}
             */
            uniforms(program, textures) {
                const decls = {};
                const count = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS);  check("getProgramParameter");
                _.range(count).map(i => {
                    const x = gl.getActiveUniform(program, i);                      check(`getActiveUniform:${i}`);
                    return x;
                }).filter(e => !!e).forEach(e => {
                    const location = gl.getUniformLocation(program, e.name);        check(`getUniformLocation:${e.name}`);
                    decls[e.name] = {name: e.name, type: e.type, size: e.size, location: location};
                });

                function assign(name, v) {
                    const decl = decls[name] || {}, loc = decl.location;
                    // console.log(`uniform ${name}: ${v}`);
                    switch (decl.type) {
                        case gl.FLOAT:
                            return isArrayLike(v) ? gl.uniform1fv(loc, v) : gl.uniform1f(loc, v);
                        case gl.FLOAT_VEC2:
                            return gl.uniform2fv(loc, v);
                        case gl.FLOAT_VEC3:
                            return gl.uniform3fv(loc, v);
                        case gl.FLOAT_VEC4:
                            return gl.uniform4fv(loc, v);
                        case gl.INT:
                            return isArrayLike(v) ? gl.uniform1iv(loc, v) : gl.uniform1i(loc, v);
                        case gl.INT_VEC2:
                            return gl.uniform2iv(loc, v);
                        case gl.INT_VEC3:
                            return gl.uniform3iv(loc, v);
                        case gl.INT_VEC4:
                            return gl.uniform4iv(loc, v);
                        case gl.SAMPLER_2D: {
                            const entry = textures[v];
                            if (!entry) {
                                console.warn(`uniform '${name}' refers to unknown texture '${v}'`);
                                return;
                            }
                            gl.uniform1i(loc, entry.unit);
                            return;
                        }
                        default:
                            console.warn(`uniform '${name}' has unsupported type: ${JSON.stringify(decl)}`);
                    }
                }

                return new class GLUUniforms {

                    decls() {
                        return decls;
                    }

                    /**
                     * @param values an object {name: value, ...} where value is a number, array, or an object
                     *        {unit: i, texture: t} for binding a texture to a unit and sampler2D.
                     * @returns {GLUUniforms} this
                     */
                    set(values) {
                        Object.keys(values).forEach(name => {
                            assign(name, values[name]);  check(`assign-uniform:${name}`);
                        });
                        return this;
                    }
                }();
            }

            /**
             * @param {WebGLProgram} program
             * @returns {GLUAttribs}
             */
            attribs(program) {
                const decls = {};
                const count = gl.getProgramParameter(program, gl.ACTIVE_ATTRIBUTES);    check("getProgramParameter");
                _.range(count).map(i => {
                    const x = gl.getActiveAttrib(program, i);                           check(`getActiveAttrib:${i}`);
                    return x;
                }).filter(e => !!e).forEach(e => {
                    const location = gl.getAttribLocation(program, e.name);             check(`getAttribLocation:${e.name}`);
                    decls[e.name] = {name: e.name, type: e.type, size: e.size, location: location};
                });

                function assign(name, data) {
                    const decl = decls[name] || {}, loc = decl.location;
                    switch (decl.type) {
                        case gl.FLOAT_VEC2:{
                            const buffer = gl.createBuffer();                               check(`createBuffer:${name}`);
                            gl.bindBuffer(gl.ARRAY_BUFFER, buffer);                         check(`bindBuffer:${name}`);
                            gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);           check(`bufferData:${name}`);
                            gl.enableVertexAttribArray(loc);                                check(`enableVertexAttribArray:${name}`);
                            return gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);}
                        default:
                            console.warn(`attribute '${name}' has unsupported type: ${JSON.stringify(decl)}`);
                    }
                }

                return new class GLUAttribs {

                    decls() {
                        return decls;
                    }

                    /**
                     * @param values an object {name: value, ...} where value is an array.
                     * @returns {GLUAttribs} this
                     */
                    set(values) {
                        Object.keys(values).forEach(name => {
                            assign(name, values[name]);  check(`assign-attrib:${name}`);
                        });
                        return this;
                    }
                }();
            }

            /**
             * @param {Object} def texture definition
             * @param {TypedArray} pixels
             * @returns {WebGLTexture}
             */
            makeTexture2D(def, pixels) {
                const texture = gl.createTexture();                                             check("createTexture");
                gl.activeTexture(gl.TEXTURE0);                                                  check("activeTexture");
                gl.bindTexture(gl.TEXTURE_2D, texture);                                         check("bindTexture");

                const opt = Object.assign({}, defaultPixelStore, defaultTexParams, def);
                const {format, type, width, height} = opt;
                defaultPixelStoreKeys.forEach(key => {
                    gl.pixelStorei(gl[key], opt[key]);                                          check(`pixelStorei:${key}`);
                });
                pixels = type === gl.FLOAT ? prepareMissing(pixels) : pixels;
                gl.texImage2D(gl.TEXTURE_2D, 0, format, width, height, 0, format, type, pixels); check("texImage2D");
                defaultTexParamKeys.forEach(key => {
                    gl.texParameteri(gl.TEXTURE_2D, gl[key], opt[key]);                         check(`texParameteri:${key}`);
                });

                gl.bindTexture(gl.TEXTURE_2D, null);                                            check("bindTexture");
                return texture;
            }

            /**
             * @param {WebGLTexture} texture
             * @param {Object} def texture definition
             * @param {TypedArray} pixels
             */
            updateTexture2D(texture, def, pixels) {
                gl.activeTexture(gl.TEXTURE0);                                                  check("activeTexture");
                gl.bindTexture(gl.TEXTURE_2D, texture);                                         check("bindTexture");

                const opt = Object.assign({}, defaultPixelStore, defaultTexParams, def);
                const {format, type, width, height} = opt;
                defaultPixelStoreKeys.forEach(key => {
                    gl.pixelStorei(gl[key], opt[key]);                                          check(`pixelStorei:${key}`);
                });
                pixels = type === gl.FLOAT ? prepareMissing(pixels) : pixels;
                gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, width, height, format, type, pixels);  check("texSubImage2D");
                defaultTexParamKeys.forEach(key => {
                    gl.texParameteri(gl.TEXTURE_2D, gl[key], opt[key]);                         check(`texParameteri:${key}`);
                });

                gl.bindTexture(gl.TEXTURE_2D, null);                                            check("bindTexture");
                return texture;
            }

            /**
             * @param {WebGLTexture} texture
             * @param {Object} def texture definition
             * @param {Object} existing texture entry
             * @returns {boolean} true if a difference between def and existing was found and applied
             */
            updateTexture2DParams(texture, def, existing) {
                let changed = false;
                for (let i = 0; i < defaultTexParamKeys.length; i++) {
                    const key = defaultTexParamKeys[i];
                    const defaultValue = defaultTexParams[key];
                    const newValue = def[key] || defaultValue;
                    const oldValue = existing[key] || defaultValue;
                    if (newValue !== oldValue) {
                        if (!changed) {
                            changed = true;
                            gl.activeTexture(gl.TEXTURE0);                      check("activeTexture");
                            gl.bindTexture(gl.TEXTURE_2D, texture);             check("bindTexture");
                        }
                        gl.texParameteri(gl.TEXTURE_2D, gl[key], newValue);     check(`texParameteri:${key}`);
                    }
                }
                if (changed) {
                    gl.bindTexture(gl.TEXTURE_2D, null);                        check("bindTexture");
                }
                return changed;
            }

        }();
    }
}
